From 81ebc15b050b31803dcf5b091d728fe973e715c7 Mon Sep 17 00:00:00 2001 From: Aaron Schulz Date: Mon, 19 Nov 2012 17:38:17 -0800 Subject: [PATCH] [FileRepo] [FileBackend] Added support for custom file headers. * For backends that support it, custom HTTP headers can be set on files. * Added a getStreamHeaders() function to MediaHandler to let subclasses recommend header name/value pairs to be used for responses to GET/HEAD requests. For example, an OGG handler could set "X-Content-Duration". * Made LocalFile use this function to set HTTP headers of new uploads. Change-Id: I1b017e1342513f0097fe6d142aae18e819403293 --- includes/filebackend/FSFile.php | 2 +- includes/filebackend/FileBackend.php | 30 ++++++++++----- includes/filebackend/FileBackendStore.php | 26 +++++++++++++ includes/filebackend/FileOp.php | 4 +- includes/filebackend/SwiftFileBackend.php | 8 ++++ includes/filerepo/FileRepo.php | 45 +++++++++++++++-------- includes/filerepo/file/File.php | 6 ++- includes/filerepo/file/LocalFile.php | 30 ++++++++++++--- includes/media/MediaHandler.php | 9 +++++ 9 files changed, 126 insertions(+), 34 deletions(-) diff --git a/includes/filebackend/FSFile.php b/includes/filebackend/FSFile.php index fda356e0a7..5141ec5801 100644 --- a/includes/filebackend/FSFile.php +++ b/includes/filebackend/FSFile.php @@ -131,7 +131,7 @@ class FSFile { # Height, width and metadata $handler = MediaHandler::getHandler( $info['mime'] ); if ( $handler ) { - $tempImage = (object)array(); + $tempImage = (object)array(); // XXX (hack for File object) $info['metadata'] = $handler->getMetadata( $tempImage, $this->path ); $gis = $handler->getImageSize( $tempImage, $this->path, $info['metadata'] ); if ( is_array( $gis ) ) { diff --git a/includes/filebackend/FileBackend.php b/includes/filebackend/FileBackend.php index b5e231540b..25126a3cdc 100644 --- a/includes/filebackend/FileBackend.php +++ b/includes/filebackend/FileBackend.php @@ -182,7 +182,8 @@ abstract class FileBackend { * 'content' => , * 'overwrite' => , * 'overwriteSame' => , - * 'disposition' => + * 'disposition' => , + * 'headers' => # since 1.21 * ); * @endcode * @@ -194,7 +195,8 @@ abstract class FileBackend { * 'dst' => , * 'overwrite' => , * 'overwriteSame' => , - * 'disposition' => + * 'disposition' => , + * 'headers' => # since 1.21 * ) * @endcode * @@ -247,10 +249,14 @@ abstract class FileBackend { * - overwriteSame : An error will not be given if a file already * exists at the destination that has the same * contents as the new contents to be written there. - * - disposition : When supplied, the backend will add a Content-Disposition + * - disposition : If supplied, the backend will return a Content-Disposition * header when GETs/HEADs of the destination file are made. - * Backends that don't support file metadata will ignore this. - * See http://tools.ietf.org/html/rfc6266 (since 1.20). + * Backends that don't support metadata ignore this. + * See http://tools.ietf.org/html/rfc6266. (since 1.20) + * - headers : If supplied, the backend will return these headers when + * GETs/HEADs of the destination file are made. Header values + * should be smaller than 256 bytes, often options or numbers. + * Backends that don't support metadata ignore this. (since 1.21) * * $opts is an associative of boolean flags, including: * - force : Operation precondition errors no longer trigger an abort. @@ -265,10 +271,10 @@ abstract class FileBackend { * - nonJournaled : Don't log this operation batch in the file journal. * This limits the ability of recovery scripts. * - parallelize : Try to do operations in parallel when possible. - * - bypassReadOnly : Allow writes in read-only mode (since 1.20). + * - bypassReadOnly : Allow writes in read-only mode. (since 1.20) * - preserveCache : Don't clear the process cache before checking files. * This should only be used if all entries in the process - * cache were added after the files were already locked (since 1.20). + * cache were added after the files were already locked. (since 1.20) * * @remarks Remarks on locking: * File system paths given to operations should refer to files that are @@ -411,7 +417,8 @@ abstract class FileBackend { * 'op' => 'create', * 'dst' => , * 'content' => , - * 'disposition' => + * 'disposition' => , + * 'headers' => # since 1.21 * ) * @endcode * b) Copy a file system file into storage @@ -420,7 +427,8 @@ abstract class FileBackend { * 'op' => 'store', * 'src' => , * 'dst' => , - * 'disposition' => + * 'disposition' => , + * 'headers' => # since 1.21 * ) * @endcode * c) Copy a file within storage @@ -465,6 +473,10 @@ abstract class FileBackend { * header when GETs/HEADs of the destination file are made. * Backends that don't support file metadata will ignore this. * See http://tools.ietf.org/html/rfc6266 (since 1.20). + * - headers : If supplied, the backend will return these headers when + * GETs/HEADs of the destination file are made. Header values + * should be smaller than 256 bytes, often options or numbers. + * Backends that don't support metadata ignore this. (since 1.21) * * $opts is an associative of boolean flags, including: * - bypassReadOnly : Allow writes in read-only mode (since 1.20) diff --git a/includes/filebackend/FileBackendStore.php b/includes/filebackend/FileBackendStore.php index 0f435a399d..f6d0b24426 100644 --- a/includes/filebackend/FileBackendStore.php +++ b/includes/filebackend/FileBackendStore.php @@ -93,6 +93,7 @@ abstract class FileBackendStore extends FileBackend { * - content : the raw file contents * - dst : destination storage path * - disposition : Content-Disposition header value for the destination + * - headers : HTTP header name/value map * - async : Status will be returned immediately if supported. * If the status is OK, then its value field will be * set to a FileBackendStoreOpHandle object. @@ -130,6 +131,7 @@ abstract class FileBackendStore extends FileBackend { * - src : source path on disk * - dst : destination storage path * - disposition : Content-Disposition header value for the destination + * - headers : HTTP header name/value map * - async : Status will be returned immediately if supported. * If the status is OK, then its value field will be * set to a FileBackendStoreOpHandle object. @@ -1066,6 +1068,9 @@ abstract class FileBackendStore extends FileBackend { wfProfileIn( __METHOD__ . '-' . $this->name ); $status = Status::newGood(); + // Fix up custom header name/value pairs... + $ops = array_map( array( $this, 'stripInvalidHeadersFromOp' ), $ops ); + // Build up a list of FileOps... $performOps = $this->getOperationsInternal( $ops ); @@ -1115,6 +1120,9 @@ abstract class FileBackendStore extends FileBackend { wfProfileIn( __METHOD__ . '-' . $this->name ); $status = Status::newGood(); + // Fix up custom header name/value pairs... + $ops = array_map( array( $this, 'stripInvalidHeadersFromOp' ), $ops ); + $supportedOps = array( 'create', 'store', 'copy', 'move', 'delete', 'null' ); $async = ( $this->parallelize === 'implicit' ); $maxConcurrency = $this->concurrency; // throttle @@ -1206,6 +1214,24 @@ abstract class FileBackendStore extends FileBackend { return array(); } + /** + * Strip long HTTP headers from a file operation + * + * @param $op array Same format as doOperation() + * @return Array + */ + protected function stripInvalidHeadersFromOp( array $op ) { + if ( isset( $op['headers'] ) ) { + foreach ( $op['headers'] as $name => $value ) { + if ( strlen( $name ) > 255 || strlen( $value ) > 255 ) { + trigger_error( "Header '$name: $value' is too long." ); + unset( $op['headers'][$name] ); + } + } + } + return $op; + } + /** * @see FileBackend::preloadCache() */ diff --git a/includes/filebackend/FileOp.php b/includes/filebackend/FileOp.php index 49111d9b3b..60be5eea20 100644 --- a/includes/filebackend/FileOp.php +++ b/includes/filebackend/FileOp.php @@ -466,7 +466,7 @@ abstract class FileOp { class CreateFileOp extends FileOp { protected function allowedParams() { return array( array( 'content', 'dst' ), - array( 'overwrite', 'overwriteSame', 'disposition' ) ); + array( 'overwrite', 'overwriteSame', 'disposition', 'headers' ) ); } protected function doPrecheck( array &$predicates ) { @@ -529,7 +529,7 @@ class StoreFileOp extends FileOp { */ protected function allowedParams() { return array( array( 'src', 'dst' ), - array( 'overwrite', 'overwriteSame', 'disposition' ) ); + array( 'overwrite', 'overwriteSame', 'disposition', 'headers' ) ); } /** diff --git a/includes/filebackend/SwiftFileBackend.php b/includes/filebackend/SwiftFileBackend.php index 48db9d3c3f..624374b3d8 100644 --- a/includes/filebackend/SwiftFileBackend.php +++ b/includes/filebackend/SwiftFileBackend.php @@ -236,6 +236,10 @@ class SwiftFileBackend extends FileBackendStore { if ( isset( $params['disposition'] ) ) { $obj->headers['Content-Disposition'] = $this->truncDisp( $params['disposition'] ); } + // Set any other custom headers if requested + if ( isset( $params['headers'] ) ) { + $obj->headers += $params['headers']; + } if ( !empty( $params['async'] ) ) { // deferred $op = $obj->write_async( $params['content'] ); $status->value = new SwiftFileOpHandle( $this, $params, 'Create', $op ); @@ -315,6 +319,10 @@ class SwiftFileBackend extends FileBackendStore { if ( isset( $params['disposition'] ) ) { $obj->headers['Content-Disposition'] = $this->truncDisp( $params['disposition'] ); } + // Set any other custom headers if requested + if ( isset( $params['headers'] ) ) { + $obj->headers += $params['headers']; + } if ( !empty( $params['async'] ) ) { // deferred wfSuppressWarnings(); $fp = fopen( $params['src'], 'rb' ); diff --git a/includes/filerepo/FileRepo.php b/includes/filerepo/FileRepo.php index 651ee27127..91fbaab7d4 100644 --- a/includes/filerepo/FileRepo.php +++ b/includes/filerepo/FileRepo.php @@ -1027,18 +1027,25 @@ class FileRepo { * Returns a FileRepoStatus object. On success, the value contains "new" or * "archived", to indicate whether the file was new with that name. * + * Options to $options include: + * - headers : name/value map of HTTP headers to use in response to GET/HEAD requests + * * @param $srcPath String: the source file system path, storage path, or URL * @param $dstRel String: the destination relative path * @param $archiveRel String: the relative path where the existing file is to * be archived, if there is one. Relative to the public zone root. * @param $flags Integer: bitfield, may be FileRepo::DELETE_SOURCE to indicate * that the source file should be deleted if possible + * @param $options Array Optional additional parameters * @return FileRepoStatus */ - public function publish( $srcPath, $dstRel, $archiveRel, $flags = 0 ) { + public function publish( + $srcPath, $dstRel, $archiveRel, $flags = 0, array $options = array() + ) { $this->assertWritableRepo(); // fail out if read-only - $status = $this->publishBatch( array( array( $srcPath, $dstRel, $archiveRel ) ), $flags ); + $status = $this->publishBatch( + array( array( $srcPath, $dstRel, $archiveRel, $options ) ), $flags ); if ( $status->successCount == 0 ) { $status->ok = false; } @@ -1054,7 +1061,8 @@ class FileRepo { /** * Publish a batch of files * - * @param $triplets Array: (source, dest, archive) triplets as per publish() + * @param $triplets Array: (source, dest, archive) triplets or + * (source, dest, archive, options) quartets as per publish(). * @param $flags Integer: bitfield, may be FileRepo::DELETE_SOURCE to indicate * that the source files should be deleted if possible * @throws MWException @@ -1077,6 +1085,7 @@ class FileRepo { // Validate each triplet and get the store operation... foreach ( $triplets as $i => $triplet ) { list( $srcPath, $dstRel, $archiveRel ) = $triplet; + $options = isset( $triplet[3] ) ? $triplet[3] : array(); // Resolve source to a storage path if virtual $srcPath = $this->resolveToStoragePath( $srcPath ); if ( !$this->validateFilename( $dstRel ) ) { @@ -1100,6 +1109,9 @@ class FileRepo { return $this->newFatal( 'directorycreateerror', $archiveDir ); } + // Set any desired headers to be use in GET/HEAD responses + $headers = isset( $options['headers'] ) ? $options['headers'] : array(); + // Archive destination file if it exists. // This will check if the archive file also exists and fail if does. // This is a sanity check to avoid data loss. On Windows and Linux, @@ -1117,25 +1129,28 @@ class FileRepo { if ( FileBackend::isStoragePath( $srcPath ) ) { if ( $flags & self::DELETE_SOURCE ) { $operations[] = array( - 'op' => 'move', - 'src' => $srcPath, - 'dst' => $dstPath, - 'overwrite' => true // replace current + 'op' => 'move', + 'src' => $srcPath, + 'dst' => $dstPath, + 'overwrite' => true, // replace current + 'headers' => $headers ); } else { $operations[] = array( - 'op' => 'copy', - 'src' => $srcPath, - 'dst' => $dstPath, - 'overwrite' => true // replace current + 'op' => 'copy', + 'src' => $srcPath, + 'dst' => $dstPath, + 'overwrite' => true, // replace current + 'headers' => $headers ); } } else { // FS source path $operations[] = array( - 'op' => 'store', - 'src' => $srcPath, - 'dst' => $dstPath, - 'overwrite' => true // replace current + 'op' => 'store', + 'src' => $srcPath, + 'dst' => $dstPath, + 'overwrite' => true, // replace current + 'headers' => $headers ); if ( $flags & self::DELETE_SOURCE ) { $sourceFSFilesToDelete[] = $srcPath; diff --git a/includes/filerepo/file/File.php b/includes/filerepo/file/File.php index 9a080ae04b..50bda52e1a 100644 --- a/includes/filerepo/file/File.php +++ b/includes/filerepo/file/File.php @@ -1390,17 +1390,21 @@ abstract class File { * The archive name should be passed through to recordUpload for database * registration. * + * Options to $options include: + * - headers : name/value map of HTTP headers to use in response to GET/HEAD requests + * * @param $srcPath String: local filesystem path to the source image * @param $flags Integer: a bitwise combination of: * File::DELETE_SOURCE Delete the source file, i.e. move * rather than copy + * @param $options Array Optional additional parameters * @return FileRepoStatus object. On success, the value member contains the * archive name, or an empty string if it was a new file. * * STUB * Overridden by LocalFile */ - function publish( $srcPath, $flags = 0 ) { + function publish( $srcPath, $flags = 0, array $options = array() ) { $this->readOnlyError(); } diff --git a/includes/filerepo/file/LocalFile.php b/includes/filerepo/file/LocalFile.php index f0a3c15e35..c2c17f5713 100644 --- a/includes/filerepo/file/LocalFile.php +++ b/includes/filerepo/file/LocalFile.php @@ -951,7 +951,7 @@ class LocalFile extends File { /** * Upload a file and record it in the DB - * @param $srcPath String: source storage path or virtual URL + * @param $srcPath String: source storage path, virtual URL, or filesystem path * @param $comment String: upload description * @param $pageText String: text to use for the new description page, * if a new description page is created @@ -972,11 +972,27 @@ class LocalFile extends File { return $this->readOnlyFatalStatus(); } + if ( !$props ) { + wfProfileIn( __METHOD__ . '-getProps' ); + $props = FileBackend::isStoragePath( $srcPath ) + ? $this->repo->getFileProps( $srcPath ) + : FSFile::getPropsFromPath( $srcPath ); + wfProfileOut( __METHOD__ . '-getProps' ); + } + + $options = array(); + $handler = MediaHandler::getHandler( $props['mime'] ); + if ( $handler ) { + $options['headers'] = $handler->getStreamHeaders( $props['metadata'] ); + } else { + $options['headers'] = array(); + } + // truncate nicely or the DB will do it for us // non-nicely (dangling multi-byte chars, non-truncated version in cache). $comment = $wgContLang->truncate( $comment, 255 ); $this->lock(); // begin - $status = $this->publish( $srcPath, $flags ); + $status = $this->publish( $srcPath, $flags, $options ); if ( $status->successCount > 0 ) { # Essentially we are displacing any existing current file and saving @@ -1252,11 +1268,12 @@ class LocalFile extends File { * @param $srcPath String: local filesystem path to the source image * @param $flags Integer: a bitwise combination of: * File::DELETE_SOURCE Delete the source file, i.e. move rather than copy + * @param $options Array Optional additional parameters * @return FileRepoStatus object. On success, the value member contains the * archive name, or an empty string if it was a new file. */ - function publish( $srcPath, $flags = 0 ) { - return $this->publishTo( $srcPath, $this->getRel(), $flags ); + function publish( $srcPath, $flags = 0, array $options = array() ) { + return $this->publishTo( $srcPath, $this->getRel(), $flags, $options ); } /** @@ -1270,10 +1287,11 @@ class LocalFile extends File { * @param $dstRel String: target relative path * @param $flags Integer: a bitwise combination of: * File::DELETE_SOURCE Delete the source file, i.e. move rather than copy + * @param $options Array Optional additional parameters * @return FileRepoStatus object. On success, the value member contains the * archive name, or an empty string if it was a new file. */ - function publishTo( $srcPath, $dstRel, $flags = 0 ) { + function publishTo( $srcPath, $dstRel, $flags = 0, array $options = array() ) { if ( $this->getRepo()->getReadOnlyReason() !== false ) { return $this->readOnlyFatalStatus(); } @@ -1283,7 +1301,7 @@ class LocalFile extends File { $archiveName = wfTimestamp( TS_MW ) . '!'. $this->getName(); $archiveRel = 'archive/' . $this->getHashPath() . $archiveName; $flags = $flags & File::DELETE_SOURCE ? LocalRepo::DELETE_SOURCE : 0; - $status = $this->repo->publish( $srcPath, $dstRel, $archiveRel, $flags ); + $status = $this->repo->publish( $srcPath, $dstRel, $archiveRel, $flags, $options ); if ( $status->value == 'new' ) { $status->value = ''; diff --git a/includes/media/MediaHandler.php b/includes/media/MediaHandler.php index 965099fdad..558a9b684f 100644 --- a/includes/media/MediaHandler.php +++ b/includes/media/MediaHandler.php @@ -243,6 +243,15 @@ abstract class MediaHandler { return array( $ext, $mime ); } + /** + * Get useful response headers for GET/HEAD requests for a file with the given metadata + * @param $metadata mixed Result this handlers getMetadata() for a file + * @return Array + */ + public function getStreamHeaders( $metadata ) { + return array(); + } + /** * True if the handled types can be transformed * @return bool -- 2.20.1